Skip to content

feat(agent): worker cloud-init + systemd + bearer auth (FJB-94 Phase B)#111

Merged
hstern merged 5 commits into
mainfrom
feat/fjb-94-agent-deploy
May 29, 2026
Merged

feat(agent): worker cloud-init + systemd + bearer auth (FJB-94 Phase B)#111
hstern merged 5 commits into
mainfrom
feat/fjb-94-agent-deploy

Conversation

@hstern

@hstern hstern commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Second slice of FJB-94. Lands the worker deployment story for fjbagent. Cache deploy + orchestrator dialer is Phase C. Opt-in: empty -fjbagent-token-file keeps current behaviour unchanged.

Design decisions worth flagging

  • No per-deployment agent version. Agent and orchestrator share a proto and must come from the same commit. The agent's version is implicitly the orchestrator's build version (linker-stamped main.version). No -fjbagent-version flag.
  • Pre-existing workers from before an upgrade keep their old agent. That's a reality, not a problem — the proto wire-compatibility discipline (additive-only, no renumber/rename) is what lets the upgraded orchestrator dial them. Codified in the service AgentService doc comment.
  • Type=notify for the systemd unit. Inlined the NOTIFY_SOCKET protocol in internal/agent/sdnotify.go (4 syscalls, no go-systemd dep).
  • Bearer auth over plain HTTP/2 cleartext. WG authenticates the orchestrator↔cache leg; cache↔worker is on the private VPC. TLS would mean another cert lifecycle.

Wiring

-fjbagent-token-file <path>   # required; empty disables agent install
-fjbagent-url <template>      # optional override; default = github releases
                              #   {{.Version}} → orchestrator's build version
                              #   $rarch       → cloud-init substitutes at boot

Cloud-init artifacts (gated on Params.FJBAgentDownloadURL being non-empty):

  • fjb system user + group (nologin, no home)
  • /etc/fjbagent/auth.token mode 0600 owned by fjb:fjb
  • /etc/default/fjbagent with FJBAGENT_LISTEN=0.0.0.0:9001
  • /etc/systemd/system/fjbagent.service (NoNewPrivileges, ProtectSystem=strict, hardening from the design doc)
  • /usr/local/bin/fjbagent fetched from the resolved URL
  • systemctl enable --now fjbagent

Heads-up — release pipeline TODO

The default URL points at github.com/hstern/fj-bellows/releases/download/v<orchestrator-version>/fjbagent-linux-$rarch. The current release pipeline does not yet publish that artifact — it only publishes the docker image. A follow-up .github/workflows/ci.yml change is needed to cross-compile fjbagent on tag pushes before any deployment can set -fjbagent-token-file and have it actually work. Until that lands, the flag is dormant (legacy default behaviour preserved).

Test plan

  • 5 new bootstrap render tests (no-artifacts when URL empty, full-render asserts, token-required guard, URL template resolution).
  • Existing render tests still green.
  • go test -race ./... full repo green.
  • golangci-lint clean.
  • Real-Linode e2e — gated by an actual fjbagent binary at the resolved URL. Will hit 404 today; the test will run after the release-pipeline follow-up.

🤖 Generated with Claude Code

Lands the deployment story for fjbagent on workers — cloud-init
installs the binary, drops a systemd unit + env file + auth token,
creates the unprivileged `fjb` system user, enables the service.
Opt-in: empty -fjbagent-token-file disables agent install entirely
so this is a no-op for deployments that haven't migrated yet.

Per-deployment knobs:
  - -fjbagent-token-file <path>   shared-secret bearer-token file;
                                  empty = agent install disabled.
  - -fjbagent-url <template>      URL template; {{.Version}} resolves
                                  to this daemon's build version,
                                  $rarch is left for cloud-init to
                                  substitute at boot. Default points
                                  at github releases for the matching
                                  fj-bellows version.

The agent's version is implicitly the same as the orchestrator's
build (linker-stamped main.version) — there is no per-deployment
agent version flag, because agent + orchestrator share a proto and
must come from the same commit. Pre-existing workers from before an
orchestrator upgrade keep running the agent version they were
provisioned with; the proto's wire-compatibility discipline (no
field renumbering, additive only) is the contract that keeps the
upgraded orchestrator able to dial them.

Deploy artifacts (added to cloud-init.yaml.tmpl, gated on
FJBAgentDownloadURL being non-empty):
  - groups: [fjb], users: [{name: fjb, system, nologin}]
  - /etc/fjbagent/auth.token (mode 0600, owned by fjb:fjb)
  - /etc/default/fjbagent (FJBAGENT_LISTEN=0.0.0.0:9001)
  - /etc/systemd/system/fjbagent.service (Type=notify, hardening
    flags from the design doc)
  - /usr/local/bin/fjbagent fetched from the resolved URL
  - systemctl enable --now fjbagent

Agent gets sd_notify support so Type=notify works — net.DialUnix to
$NOTIFY_SOCKET, write "READY=1\n", inline (no go-systemd dep).

NOTE: this PR ships the wiring but does not by itself publish the
fjbagent binary at the default github releases URL. The release
pipeline (.github/workflows/ci.yml) needs a parallel change to
cross-compile fjbagent for linux-amd64 / linux-arm64 and attach the
artifacts to v* tag releases before any deployment can set
-fjbagent-token-file in production. Tracked in FJB-94 phase
follow-ups.

Tests:
  - 5 new bootstrap render tests: backward-compat (no artifacts when
    URL empty), full-render asserts (user creation, token file mode,
    systemd unit text, runcmd), token-required guard, URL resolution
    across default + custom templates.
  - Existing render tests + full repo `go test -race ./...` green.
  - golangci-lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hstern hstern enabled auto-merge (squash) May 28, 2026 23:45
hstern and others added 3 commits May 28, 2026 21:08
The Phase B token-load added one branch to run() that pushed it from
13 to 14, which is over .golangci.yml's gocyclo budget. Extract the
orchestrator.Config construction (and the FJB-94 token-load that
sits in front of it) into buildOrchestratorConfig so run()'s
complexity is back under.

Locally golangci-lint v2.12.1 didn't flag this in my workspace —
CI's run on Linux did. Cause: probably gocyclo cache differences.
The fix is purely structural; no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…B-94 Phase B)

CI's gocyclo flagged run() at complexity 14 even after the earlier
buildOrchestratorConfig extraction — main has moved forward since
Phase B branched, and the new plumbOrchestratorWGPubkey branch
pushed it back over. Extract two more small helpers to bring it
down: loadSSHKeyForProvider folds the docker-vs-ssh check, and
applyAuthorizedKeyToLinodeProvider folds the Linode duck-type
wiring. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hstern hstern merged commit 01da48f into main May 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant